Skip to content

feat: add Elysia adapter#46

Merged
tomaspozo merged 9 commits into
supabase:mainfrom
wobsoriano:rob/elysia-adapter
May 19, 2026
Merged

feat: add Elysia adapter#46
tomaspozo merged 9 commits into
supabase:mainfrom
wobsoriano:rob/elysia-adapter

Conversation

@wobsoriano
Copy link
Copy Markdown
Contributor

@wobsoriano wobsoriano commented May 5, 2026

What kind of change does this PR introduce?

Feature

What is the current behavior?

@supabase/server ships framework adapters for Hono and H3 / Nuxt, but not for Elysia. Users building APIs with Elysia have to wire up Supabase auth manually with the core primitives.

What is the new behavior?

Adds @supabase/server/adapters/elysia, a first-class plugin adapter for Elysia. Elysia has been gaining significant traction in the TypeScript ecosystem, particularly in the Bun community, for its end-to-end type safety and expressive plugin API.

Usage with a plain Elysia app:

import { Elysia } from 'elysia'
import { withSupabase } from '@supabase/server/adapters/elysia'

const app = new Elysia()
  .use(withSupabase({ auth: 'user' }))
  .get('/games', async ({ supabaseContext }) => {
    const { data: myGames } = await supabaseContext.supabase
      .from('favorite_games')
      .select()

    return myGames
  })

app.listen(3000)

Per-route auth using scoped groups:

const app = new Elysia()
  .get('/health', () => ({ status: 'ok' }))
  .group('/api', (app) =>
    app
      .use(withSupabase({ auth: 'user' }))
      .get('/profile', ({ supabaseContext }) => supabaseContext.userClaims),
  )

Custom error handling via Elysia's onError:

const app = new Elysia()
  .use(withSupabase({ auth: 'user' }))
  .onError(({ code, error, status }) => {
    if (code !== 'SupabaseAuthError') return

    const cause = error.cause as { code?: string; status?: number } | undefined

    return status((cause?.status as 401) ?? 500, {
      error: error.message,
      code: cause?.code,
    })
  })

Additional context

  • Uses Elysia's .resolve() hook with .as('scoped') so the context propagates to parent app routes.
  • Auth failures throw a registered SupabaseAuthError, which integrates with Elysia's standard onError flow and preserves the original AuthError on error.cause.Consumers discriminate on code === 'SupabaseAuthError' in onError without needing to import the class.
  • Test coverage mirrors the Hono and H3 adapter tests.
  • Docs and package metadata were updated to include Elysia in the adapter tables, exports, and generated API docs.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@supabase/server@46

commit: 1217b8d

@wobsoriano wobsoriano marked this pull request as ready for review May 5, 2026 17:49
@wobsoriano wobsoriano requested review from a team as code owners May 12, 2026 23:13
@wobsoriano wobsoriano force-pushed the rob/elysia-adapter branch from 396ca1a to 2633af3 Compare May 12, 2026 23:14
Comment thread src/adapters/elysia/plugin.ts Outdated
Comment on lines +6 to +13
class SupabaseAuthError extends Error {
status: number
constructor(message: string, status: number, cause: unknown) {
super(message, { cause })
this.status = status
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really sure why its SupabaseAuthError.
We have EnvError and AuthError classes.

Both sharing similar shape. Maybe better renaming it to SupabaseError instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! Renamed to SupabaseError.

The intent behind a wrapper class rather than throwing AuthError directly is to give Elysia consumers a single, stable discriminant in onError regardless of whether the failure was an auth error or an env misconfiguration.

More info https://elysiajs.com/patterns/error-handling

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhmm so what about registering the original error classes instead of wrapping it?

new Elysia()
  .error({
      EnvError,
      AuthError,
  })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I tried throwing AuthError directly with .error({ AuthError }), but Elysia's error registry inside a scoped plugin doesn't propagate to the parent's onError at runtime, so code === 'AuthError' silently fails even though TS infers it correctly.

We could keep a thin SupabaseError wrapper but expose .code and .status directly on it (no .cause indirection). That way code discrimination still works idiomatically. Open to other suggestions!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if we pass the AuthError directly, one will check it in the elysia error hook like this:

import { AuthError } from '@supabase/server/errors'

app.onError(({ error, status }) => {
  if (!(error instanceof AuthError)) return
  return status(error.status as 401, {
    error: error.message,
    code: error.code,
  })
})

but if we create a thin wrapper and expose .code and .status:

app.onError(({ code, error, status }) => {
  if (code !== 'SupabaseError') return
  return status(error.status as 401, {
    error: error.message,
    code: error.code,
  })
})

2nd is more idiomatic to Elysia, no extra import, and the code string is the standard discriminant. The first works but bypasses Elysia's custom error system entirely

Copy link
Copy Markdown
Member

@kallebysantos kallebysantos May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2nd is more idiomatic to Elysia

Sure! so keep the version you think better aligns to Elysia framework.
Mark it as "resolved" with your confirmation

Comment thread src/adapters/README.md
Comment thread package.json Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
@kallebysantos
Copy link
Copy Markdown
Member

Thanks for contributing,
Just a few questions and adjustments 💚

@wobsoriano
Copy link
Copy Markdown
Contributor Author

@kallebysantos thanks for the thorough review. Resolved the comments! ❤️

Copy link
Copy Markdown
Member

@kallebysantos kallebysantos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
Please fix the merge conflicts as well give me the confirmation about SupabaseError thing.

Then we should be good to merge!

@wobsoriano wobsoriano force-pushed the rob/elysia-adapter branch from b3a3438 to 1217b8d Compare May 16, 2026 15:17
@wobsoriano
Copy link
Copy Markdown
Contributor Author

LGTM! Please fix the merge conflicts as well give me the confirmation about SupabaseError thing.

Then we should be good to merge!

Thanks for approving! Going with the thin SupabaseError wrapper as it keeps Elysia's idiomatic code discrimination (code === 'SupabaseError') working while exposing .code and .status directly on the error, so no awkward .cause access 🫡

@tomaspozo
Copy link
Copy Markdown
Member

Thanks for approving! Going with the thin SupabaseError wrapper as it keeps Elysia's idiomatic code discrimination (code === 'SupabaseError') working while exposing .code and .status directly on the error, so no awkward .cause access 🫡

Hi @wobsoriano! we are ready to merge this... can you confirm it this is already taken care of fully?

@wobsoriano
Copy link
Copy Markdown
Contributor Author

wobsoriano commented May 18, 2026

Thanks for approving! Going with the thin SupabaseError wrapper as it keeps Elysia's idiomatic code discrimination (code === 'SupabaseError') working while exposing .code and .status directly on the error, so no awkward .cause access 🫡

Hi @wobsoriano! we are ready to merge this... can you confirm it this is already taken care of fully?

I just pushed an update (ref) that gives clean access to .code and .status without a type cast. Also simplified the constructor to take the AuthError directly instead of unpacking its fields.

Tested this locally in a simple Elysia app:

.onError(({ code, error, status }) => {
  if (code !== 'SupabaseError') return
  return status(error.status {
    error: error.message,
    code: error.cause.code,
  })
})

sending a curl request curl -i http://localhost:3000/ -H "Authorization: Bearer sometoken" gives

{"error":"Invalid credentials","code":"INVALID_CREDENTIALS"}

Ready to merge 👍🏼

@tomaspozo
Copy link
Copy Markdown
Member

tomaspozo commented May 19, 2026

@wobsoriano

One small suggestion before merge: would you consider hoisting code onto the wrapper too, for consistency with status?

Right now .status lives directly on SupabaseError but .code still requires going through .cause...

 class SupabaseError extends Error {
   status: number
   code: string
   declare cause: AuthError
   constructor(inner: AuthError) {
     super(inner.message, { cause: inner })
     this.status = inner.status
     this.code = inner.code
   }
 }

Then the handler becomes uniform:

  .onError(({ code, error, status }) => {
    if (code !== 'SupabaseError') return
    return status(error.status as 401, {
      error: error.message,
      code: error.code,
    })
  })

cause is still there as the typed AuthError for anyone who wants the full object. Happy to merge as-is if you'd rather keep the wrapper minimal.. Flagging since it's a one-liner 😃

@wobsoriano
Copy link
Copy Markdown
Contributor Author

@tomaspozo

Unfortunately it collides with an Elysia internal: when a thrown error has an own .code property, Elysia uses its value as the code in onError instead of the .error() registry key. So with this.code = inner.code, the handler sees code === 'INVALID_CREDENTIALS' instead of code === 'SupabaseError', and the discrimination check never passes.

That's also why .status is safe to hoist but .code isn't. Elysia doesn't treat .status specially. Keeping .code off the wrapper and behind .cause.code is the workaround.

So if you have this in onError:

.onError(({ code, error }) => {
  console.log('code value:', code)
  console.log('code === SupabaseError:', code === 'SupabaseError')
  
  if (code !== 'SupabaseError') return
  
  // ...
 })

The output is:

  code value: INVALID_CREDENTIALS
  code === SupabaseError: false

@tomaspozo
Copy link
Copy Markdown
Member

Got it! Thanks for the explanation. Going to merge this now, and create a new release!

@tomaspozo tomaspozo merged commit 148169e into supabase:main May 19, 2026
bogdantarasenko added a commit to bogdantarasenko/server that referenced this pull request May 20, 2026
Resolves conflicts with the Elysia adapter (supabase#46) and the 1.1.0 release
that landed on main while review feedback was being addressed.

- README.md / src/adapters/README.md: list both Elysia and NestJS in
  the adapter tables; preserve the community-driven note added upstream.
- package.json / jsr.json / tsdown.config.ts: combine Elysia + NestJS
  entries, peer deps, and bundle externals. Keep upstream's
  `@supabase/supabase-js` ^2.105.4 bump.
- pnpm-workspace.yaml: set `allowBuilds` policy for `@nestjs/core` and
  `@swc/core` to `false` — they're dev-only deps with no native
  bindings we need to compile.
- pnpm-lock.yaml: regenerated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants